<?php

namespace Ignus\View;

use Ignus\View\Error\Loader;
use Ignus\View\Error\Runtime;

use Ignus\View\Extension\Core;
use Ignus\View\Extension\Escaper;
use Ignus\View\Extension\Optimizer;

class Environment
{
    protected $charset;

    /**
     * @var \Ignus\View\LoaderInterface
     */
    protected $loader;

    protected $debug;
    protected $autoReload;
    protected $cache;
    protected $lexer;
    protected $parser;
    protected $compiler;
    protected $baseTemplateClass;
    protected $extensions;

    /**
     * @var \Ignus\View\TokenParserBroker
     */
    protected $parsers;

    protected $visitors;
    protected $filters;
    protected $tests;
    protected $functions;
    protected $globals;
    protected $runtimeInitialized;
    protected $loadedTemplates;
    protected $strictVariables;
    protected $unaryOperators;
    protected $binaryOperators;
    protected $templateClassPrefix = '__IgnusTemplate_';
    protected $functionCallbacks;
    protected $filterCallbacks;

    /**
     * @param LoaderInterface|null $loader
     * @param array $options
     */
    public function __construct(LoaderInterface $loader = null, $options = array())
    {
        if (null !== $loader) {
            $this->setLoader($loader);
        }

        $options = array_merge(array(
            'debug'               => false,
            'charset'             => 'UTF-8',
            'cache'               => false,
            'auto_reload'         => null,
            'optimizations'       => -1,
        ), $options);

        $options['autoescape'] = false;
        $options['base_template_class'] = '\Ignus\View\Template';

        $this->debug              = (bool) $options['debug'];
        $this->charset            = $options['charset'];
        $this->baseTemplateClass  = $options['base_template_class'];
        $this->autoReload         = null === $options['auto_reload'] ? $this->debug : (bool) $options['auto_reload'];
        $this->extensions         = array(
            'core'      => new Core(),
            'escaper'   => new Escaper((bool) $options['autoescape']),
            'optimizer' => new Optimizer($options['optimizations']),
        );
        $this->runtimeInitialized = false;
        $this->setCache($options['cache']);
        $this->functionCallbacks = array();
        $this->filterCallbacks = array();
    }

    public function getBaseTemplateClass()
    {
        return $this->baseTemplateClass;
    }

    public function isDebug()
    {
        return $this->debug;
    }

    public function isAutoReload()
    {
        return $this->autoReload;
    }

    public function getCache()
    {
        return $this->cache;
    }

    public function setCache($cache)
    {
        $this->cache = $cache ? $cache : false;
    }

    public function getCacheFilename($name)
    {
        if (false === $this->cache) {
            return false;
        }

        $class = substr($this->getTemplateClass($name), strlen($this->templateClassPrefix));

        return $this->getCache().'/'.substr($class, 0, 2).'/'.substr($class, 2, 2).'/'.substr($class, 4).'.php';
    }

    public function getTemplateClass($name)
    {
        return $this->templateClassPrefix.md5($this->loader->getCacheKey($name));
    }

    public function getTemplateClassPrefix()
    {
        return $this->templateClassPrefix;
    }

    public function render($name, array $context = array())
    {
        return $this->loadTemplate($name)->render($context);
    }

    /**
     * @param string $name Template name
     * @return \Ignus\View\Template
     */
    public function loadTemplate($name)
    {
        $cls = $this->getTemplateClass($name);

        if (isset($this->loadedTemplates[$cls])) {
            return $this->loadedTemplates[$cls];
        }

        if (!class_exists($cls, false)) {
            if (false === $cache = $this->getCacheFilename($name)) {
                eval('?>'.$this->compileSource($this->loader->getSource($name), $name));
            } else {
                if (!is_file($cache) || ($this->isAutoReload() && !$this->isTemplateFresh($name, filemtime($cache)))) {
                    $this->writeCacheFile($cache, $this->compileSource($this->loader->getSource($name), $name));
                }

                require_once $cache;
            }
        }

        if (!$this->runtimeInitialized) {
            $this->initRuntime();
        }

        return $this->loadedTemplates[$cls] = new $cls($this);
    }

    /**
     * @param string $name The template name
     * @param timestamp $time The last modification time of the cached template
     * @return Boolean true if the template is fresh, false otherwise
     */
    public function isTemplateFresh($name, $time)
    {
        foreach ($this->extensions as $extension) {
            $r = new \ReflectionObject($extension);
            if (filemtime($r->getFileName()) > $time) {
                return false;
            }
        }

        return $this->loader->isFresh($name, $time);
    }

    public function resolveTemplate($names)
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        $e = false;

        foreach ($names as $name) {
            if ($name instanceof Template) {
                return $name;
            }

            try {
                return $this->loadTemplate($name);
            } catch (Loader $e) {
            }
        }

        if (1 === count($names)) {
            throw $e;
        }

        throw new Loader(sprintf('Unable to find one of the following templates: "%s".', implode('", "', $names)));
    }

    public function clearTemplateCache()
    {
        $this->loadedTemplates = array();
    }

    public function clearCacheFiles()
    {
        if (false === $this->cache) {
            return;
        }

        $dir = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->cache), \RecursiveIteratorIterator::LEAVES_ONLY);
        foreach ($dir as $file) {
            /** @var \FilesystemIterator $file */
            if ($file->isFile()) {
                @unlink($file->getPathname());
            }
        }
    }

    /**
     * @return LexerInterface
     */
    public function getLexer()
    {
        if (null === $this->lexer) {
            $this->lexer = new Lexer($this);
        }

        return $this->lexer;
    }

    public function setLexer(LexerInterface $lexer)
    {
        $this->lexer = $lexer;
    }

    /**
     * @param mixed $source
     * @param string|null $name
     * @return \Ignus\View\TokenStream
     */
    public function tokenize($source, $name = null)
    {
        return $this->getLexer()->tokenize($source, $name);
    }

    /**
     * @return \Ignus\View\ParserInterface
     */
    public function getParser()
    {
        if (null === $this->parser) {
            $this->parser = new Parser($this);
        }

        return $this->parser;
    }

    public function setParser(ParserInterface $parser)
    {
        $this->parser = $parser;
    }

    /**
     * @return \Ignus\View\Node\Module
     */
    public function parse(TokenStream $tokens)
    {
        return $this->getParser()->parse($tokens);
    }

    /**
     * @return \Ignus\View\CompilerInterface
     */
    public function getCompiler()
    {
        if (null === $this->compiler) {
            $this->compiler = new Compiler($this);
        }

        return $this->compiler;
    }

    public function setCompiler(CompilerInterface $compiler)
    {
        $this->compiler = $compiler;
    }

    public function compile(NodeInterface $node)
    {
        return $this->getCompiler()->compile($node)->getSource();
    }

    public function compileSource($source, $name = null)
    {
        try {
            return $this->compile($this->parse($this->tokenize($source, $name)));
        } catch (Error $e) {
            $e->setTemplateFile($name);
            throw $e;
        } catch (\Exception $e) {
            throw new Runtime(sprintf('An exception has been thrown during the compilation of a template ("%s").', $e->getMessage()), -1, $name, $e);
        }
    }

    public function setLoader(LoaderInterface $loader)
    {
        $this->loader = $loader;
    }

    /**
     * @return LoaderInterface
     */
    public function getLoader()
    {
        return $this->loader;
    }

    public function setCharset($charset)
    {
        $this->charset = $charset;
    }

    public function getCharset()
    {
        return $this->charset;
    }

    public function initRuntime()
    {
        $this->runtimeInitialized = true;

        foreach ($this->getExtensions() as $extension) {
            $extension->initRuntime($this);
        }
    }

    public function hasExtension($name)
    {
        return isset($this->extensions[$name]);
    }

    /**
     * @param string $name
     * @return \Ignus\View\Extension
     */
    public function getExtension($name)
    {
        if (!isset($this->extensions[$name])) {
            throw new \Ignus\View\Error\Runtime(sprintf('The "%s" extension is not enabled.', $name));
        }

        return $this->extensions[$name];
    }

    /**
     * @param \Ignus\View\ExtensionInterface $extension
     */
    public function addExtension(ExtensionInterface $extension)
    {
        $this->extensions[$extension->getName()] = $extension;
    }

    /**
     * @return \Ignus\View\Extension[]
     */
    public function getExtensions()
    {
        return $this->extensions;
    }

    public function addTokenParser(TokenParserInterface $parser)
    {
        if (null === $this->parsers) {
            $this->getTokenParsers();
        }

        $this->parsers->addTokenParser($parser);
    }

    public function getTokenParsers()
    {
        if (null === $this->parsers) {
            $this->parsers = new TokenParserBroker();
            foreach ($this->getExtensions() as $extension) {
                $parsers = $extension->getTokenParsers();
                foreach($parsers as $parser) {
                    if ($parser instanceof TokenParserInterface) {
                        $this->parsers->addTokenParser($parser);
                    }
                    else if ($parser instanceof TokenParserBrokerInterface) {
                        $this->parsers->addTokenParserBroker($parser);
                    }
                    else {
                        throw new Runtime('getTokenParsers() must return an array of \Ignus\View\TokenParserInterface or \Ignus\View\TokenParserBrokerInterface instances');
                    }
                }
            }
        }

        return $this->parsers;
    }

    public function addNodeVisitor(NodeVisitorInterface $visitor)
    {
        if (null === $this->visitors) {
            $this->getNodeVisitors();
        }

        $this->visitors[] = $visitor;
    }

    /**
     * @return \Ignus\View\NodeVisitorInterface[]
     */
    public function getNodeVisitors()
    {
        if (null === $this->visitors) {
            $this->visitors = array();
            foreach ($this->getExtensions() as $extension) {
                $this->visitors = array_merge($this->visitors, $extension->getNodeVisitors());
            }
        }

        return $this->visitors;
    }

    public function addFilter($name, FilterInterface $filter)
    {
        if (null === $this->filters) {
            $this->loadFilters();
        }

        $this->filters[$name] = $filter;
    }

    /**
     * @return \Ignus\View\Filter
     */
    public function getFilter($name)
    {
        if (null === $this->filters) {
            $this->loadFilters();
        }

        if (isset($this->filters[$name])) {
            return $this->filters[$name];
        }

        foreach ($this->filterCallbacks as $callback) {
            if (false !== $filter = call_user_func($callback, $name)) {
                return $filter;
            }
        }

        return false;
    }

    public function registerUndefinedFilterCallback($callable)
    {
        $this->filterCallbacks[] = $callable;
    }

    /**
     * @return \Ignus\View\FilterInterface[]
     */
    protected function loadFilters()
    {
        $this->filters = array();
        foreach ($this->getExtensions() as $extension) {
            $this->filters = array_merge($this->filters, $extension->getFilters());
        }
    }

    /**
     * @param \Ignus\View\TestInterface $visitor
     */
    public function addTest($name, TestInterface $test)
    {
        if (null === $this->tests) {
            $this->getTests();
        }

        $this->tests[$name] = $test;
    }

    /**
     * @return \Ignus\View\TestInterface[]
     */
    public function getTests()
    {
        if (null === $this->tests) {
            $this->tests = array();
            foreach ($this->getExtensions() as $extension) {
                $this->tests = array_merge($this->tests, $extension->getTests());
            }
        }

        return $this->tests;
    }

    public function addFunction($name, FunctionInterface $function)
    {
        if (null === $this->functions) {
            $this->loadFunctions();
        }

        $this->functions[$name] = $function;
    }

    /**
     * @param $name
     * @return \Ignus\View\AbstractFunction
     */
    public function getFunction($name)
    {
        if (null === $this->functions) {
            $this->loadFunctions();
        }

        if (isset($this->functions[$name])) {
            return $this->functions[$name];
        }

        foreach ($this->functionCallbacks as $callback) {
            if (false !== $function = call_user_func($callback, $name)) {
                return $function;
            }
        }

        return false;
    }

    public function registerUndefinedFunctionCallback($callable)
    {
        $this->functionCallbacks[] = $callable;
    }

    protected function loadFunctions()
    {
        $this->functions = array();
        foreach ($this->getExtensions() as $extension) {
            $this->functions = array_merge($this->functions, $extension->getFunctions());
        }
    }

    public function addGlobal($name, $value)
    {
        if (null === $this->globals) {
            $this->getGlobals();
        }

        $this->globals[$name] = $value;
    }

    /**
     * @return array An array of globals
     */
    public function getGlobals()
    {
        if (null === $this->globals) {
            $this->globals = array();
            foreach ($this->getExtensions() as $extension) {
                $this->globals = array_merge($this->globals, $extension->getGlobals());
            }
        }

        return $this->globals;
    }

    /**
     * @return \Ignus\View\ExtensionInterface[]
     */
    public function getUnaryOperators()
    {
        if (null === $this->unaryOperators) {
            $this->initOperators();
        }

        return $this->unaryOperators;
    }

    /**
     * @return \Ignus\View\ExtensionInterface[]
     */
    public function getBinaryOperators()
    {
        if (null === $this->binaryOperators) {
            $this->initOperators();
        }

        return $this->binaryOperators;
    }

    protected function initOperators()
    {
        $this->unaryOperators = array();
        $this->binaryOperators = array();
        foreach ($this->getExtensions() as $extension) {
            /** @var \Ignus\View\ExtensionInterface $extension */
            $operators = $extension->getOperators();

            if (!$operators) {
                continue;
            }

            if (2 !== count($operators)) {
                throw new \InvalidArgumentException(sprintf('"%s::getOperators()" does not return a valid operators array.', get_class($extension)));
            }

            $this->unaryOperators = array_merge($this->unaryOperators, $operators[0]);
            $this->binaryOperators = array_merge($this->binaryOperators, $operators[1]);
        }
    }

    protected function writeCacheFile($file, $content)
    {
        if (!is_dir(dirname($file))) {
            mkdir(dirname($file), 0777, true);
        }

        $tmpFile = tempnam(dirname($file), basename($file));
        if (false !== @file_put_contents($tmpFile, $content)) {
            if (@rename($tmpFile, $file)) {
                chmod($file, 0644);
                return;
            }
        }

        throw new Runtime(sprintf('Failed to write cache file "%s".', $file));
    }
}
